Remix + Cloudflare Pages + D1 + KV + Queue + R2で簡単なアプリを作る
※_worker.jsでD1が使えなくて頓挫した。ローカルでは意図した通りに動く。
目標
Cloudflare Pages/Workers製品を色々使う
RemixでCRUD系のアプリを作る
作るもの
簡単なブクマアプリを作る
データ管理にD1を使う
ログイン必須
セッション管理でKVを使う
ブックマークしたURLのOGPを取得する
画像管理にD1+R1を使う
非同期で処理するためにQueueを使う
なんか無理やりCron Triggerも使いたい
準備
プロジェクトの生成とデプロイとテスト実行。
code:sh
npm create cloudflare@latest
cd remix-workers-bookmark
npm run dev
とりあえず必要そうなライブラリを入れる。
code:sh
npm install -D prettier drizzle-kit better-sqlite3
npm install drizzle-orm remix-auth remix-auth-google remix-validated-form zod @remix-validated-form/with-zod
.gitignore。.wranglerと.dev.varsは忘れがちなので注意。というかcreate-remixをしたときに自動で生成しておいてくれ。
code:.gitignore
.DS_Store
node_modules
/.cache
/functions/\[\path\\].js.map /functions/*.json
/public/build
.dev.vars
.vscode
.wrangler
認証機能の作成
以下ではGoogleログインを追加する。
Google側での設定
準備。下記にアクセスし適当なプロジェクトを作成。
APIとサービス > OAuth同意画面へ。外部を選択し、必要情報を埋めていく。
スコープはuserinfo.emailとuserinfo.profileとopenidを選択。
テストユーザーはテストログインしたいアカウントのアドレスを登録する。
認証情報へ行きクライアントIDの発行をする。承認済みのJavascript生成元にはhttp://localhost:8788とhttps://hoge.pages.devを設定し、承認済みのリダイレクトURIにはhttp://localhost:8788/auth/google/callbackとhttps://hoge.pages.dev/auth/google/callbackを設定する。
Cloudflare側設定
touch wrangler.tomlを実行。
KV
code:sh
npx wrangler kv:namespace create session_kv
npx wrangler kv:namespace create session_kv --preview
code:wrangler.toml
kv_namespaces
binding = "SESSION_KV"
id = "..."
preview_id = "..."
Pages > ProjectName > Settings > Functions > KV namespace bindings にてSESSION_KVに作成したKVをそれぞれ指定する。
D1
code:sh
npx wrangler d1 create SAMPLE_DB
code:wrangler.toml
d1_databases
binding = "DB"
database_name = "SAMPLE_DB"
database_id = "..."
migrations_dir = "db/migrate"
code:package.json
...
"local:migrations:apply": "wrangler d1 migrations apply SAMPLE_DB --local" <- 追加
...
アプリ側の設定
クライアントIDなどを環境変数として設定。
code:sh
$ touch .dev.vars
GOOGLE_AUTH_CALLBACK_URL=
GOOGLE_AUTH_CLIENT_ID=
GOOGLE_AUTH_CLIENT_SECRET=
SESSION_SECRET=
ユーザー保持用のテーブルを作成する。drizzle kitを使う。drizzle.config.tsを作成する。
code:drizzle.config.ts
import type { Config } from "drizzle-kit";
export default {
schema: "./db/schema.ts",
out: "./db/migrate",
} satisfies Config;
code:sh
mkdir -p db/migrate
touch db/schema.ts
schemaを作成し、migrationファイルを生成する。
code:db/schema.ts
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: integer("id").primaryKey().notNull(),
profileId: text("profileId").notNull(),
iconUrl: text("iconUrl"),
displayName: text("displayName").notNull(),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});
code:package.json
..
"migrations:gen": "drizzle-kit generate:sqlite" <- scriptsに追加
..
code:sh
npm run migrations:gen
ローカルのD1へmigrationを適用する。ちなみにローカルのD1は.wrangler/配下にsqliteが配置されている。
code:sh
npm run local:migrations:apply
migrationはDBへの適用前ならdrizzle-kit drop(drizzle-kit drop – DrizzleORM)で破棄することができる。が、DBに適用済みな場合はd1_migrationsテーブルのレコード削除とdrop table <your-table-name>を手動で実行しないといけない。tableの処理まで管理してくれるrollbackコマンドはない。 実装
アプリケーションコードに作成に移る。app/配下にGoogle認証の処理を実装していく流れ。
ブックマーク機能の実装
URLをブックマークして一覧出来て編集したり削除できたりするようなCRUD機能を作っていく。
TailwindCSSの設定
remix.config.jsにtailwind: trueを追加して下記をインストール。
code:sh
npm install -D tailwindcss
npx tailwindcss init --ts
tailwind.config.tsにcontent: ["./app/**/*.{js,jsx,ts,tsx}"],を追加。
app/tailwind.cssを作成し@tailwind base; @tailwind components; @tailwind utilities;を追加。
app/root.tsxのLinkタグの指定を下記のように書き換える。
code:tsx
import styles from "./tailwind.css";
export const links: LinksFunction = () => rel: "stylesheet", href: styles };
アプリケーションコードの構造
Our general recommendation for scale is to make every route a folder and put the modules used exclusively by that route in the folder, then put the shared modules outside of routes folder elsewhere.
とあるようにスケールする作り方として推奨されていそうだった。
色々考えた結果、個人的には下記のようなルールでの運用が良さそうという結論になった。
app/routes
URLに紐づくRouteを配置する。
ディレクトリの中にはそのページでのみ使うコンポーネントなどを配置する。
app/features
Route横断で使うコンポーネントや関数などを配置する。
app/features/feature-nameのようにディレクトリを作成し、その中にcomponentsやservicesなどのように階層を設けてファイルを配置する。
app/db
データベースのスキーマファイルとマイグレーションファイルを配置する。
テーブル作成
下記のようなbookmarksテーブルのスキーマを作り、npm run migrations:genでマイグレーションファイルを生成しlocal:migrations:applyでD1へ反映。
code:ts
export const bookmarks = sqliteTable("bookmarks", {
id: integer("id").primaryKey().notNull(),
slug: text("slug").notNull(),
userId: integer("userId").notNull(),
url: text("url").notNull(),
title: text("title"),
comment: text("comment"),
ogpImageUrl: text("ogpImageUrl"),
isProcessed: integer("isProcessed", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt", { mode: "timestamp" }).notNull(),
});
実装
作成するURLは下記。
/users/:userId
/bookmarks/:bookmarkId
ブックマークの詳細ページ。編集したり削除したりするためのページ。
基本的なCRUD機能がほぼ詰まってる。結構なファイル数なので一つずつ解説するのは大変なので具体的なコードはリポジトリの方を見てほしい。
なお実装を進めていくときに何度も行ったり来たりしたドキュメントは下記。
Remixのルーティングの規則についての確認でよく使った
Drizzle ORMでどうやってDBとやりとりするのかを学ぶのによく読んだ
Form作成の参考に使った
ブックマーク先の情報の取得機能の作成
ブックマークしたURLのタイトルやOGPなどを取得する機能を作る。
Cloudflare側設定
Queues
code:sh
npx wrangler queues create bookmark-queue
code:wrangler.toml
queues.producers
binding = "QUEUE"
queue = "bookmark-queue"
queues.consumers
queue = "bookmark-queue"
max_batch_size = 10
max_batch_timeout = 30
code:env.d.ts
QUEUE: Queue<any>;
ASSETS: Fetcher;
仕方ないのでQueueの型情報を手書きして対応する。とりあえず下記だけあれば動くか?
code:ts
declare type QueueContentType = "text" | "bytes" | "json" | "v8";
declare interface Queue<Body = unknown> {
send(message: Body, options?: QueueSendOptions): Promise<void>;
sendBatch(messages: Iterable<MessageSendRequest<Body>>): Promise<void>;
}
declare interface QueueSendOptions {
contentType?: QueueContentType;
}
declare interface MessageSendRequest<Body = unknown> {
body: Body;
contentType?: QueueContentType;
}
ローカルではProducerでQueuesにsendすることはできるが、Consumerを動かせない。どうやればいいのか...。
Consumerはqueue()のworkersが必要だが多分Remixの機構では管理できないっぽい気が。
もしかしてRemix + Cloudflare PagesだとQueueを簡単には使えない?使おうとするならQueueのConsumer用のWorkersを別途作成して管理するしかないのかね。。
FunctionsはあくまでもonRequestXXXを通じてリクエスト経由でWorkerを実行するという仕組みなのでQueueのCounsumerのデプロイはここで行えないっぽい? DOで同じような問題にぶち当たった人がいた
_worker.jsにqueue()は生成されるけどwrangler pages devで実行してもqueue()がconsumerとして認識されないのでUncaught (async) Error: Handler does not export a queue() function.が出る。
public/にremixデフォルトで生成されていた_routes.jsonを消して再起動したらいけた
_routes.jsonって何?
functionsで配信するかPagesでホストするかを制御するファイルっぽい
背景としてはWorkerで静的ファイルまで配信するとworkerの実行回数がかさみ無駄に金がかかる。なので静的ファイルと動的リクエストが必要なものとをルーティングするための設定ファイルがこれらしい。
_routes.jsonを削除するのはいいけど、このままだと全てのリクエストがfunctionsで処理される。これを防ぐためにserver.tsに自前で静的ファイルを別で処理する記述を入れる。
fetchの中で_routes.jsonに書いてある/build/*と/favicon.icoのリクエストの時だけreturn env.ASSETS.fetch(request)とするだけでとりあえずOK。
Remix + Cloudflare PagesでQueueのconsumer Workerを使えるようにする方法を整理する。
Remixデフォルトのfunctions/*にサーバー用のコードが生成される仕組みを変更しpublic/_worker.jsをpublic/に生成するように変更する
server.ts内のhandlerとしてfetchとqueueを別々に定義する。
_worker.jsで静的ファイルへのリクエストの処理なども処理されるので_routes.jsonは削除する。
でもこれ本番環境だとBindingにConsumerの設定するところあるけど、_worker.jsでデプロイした場合はどうなるんだろ。
R2
code:sh
npx wrangler r2 bucket create bookmark-bucket
npx wrangler r2 bucket create bookmark-bucket-preview
code:wrangler.toml
r2_buckets
binding = "BUCKET"
bucket_name = "bookmark-bucket"
preview_bucket_name = "bookmark-bucket-preview"
code:env.d.ts
BUCKET: R2Bucket;
R2の画像へのアクセスを_worker.jsで捌くような設定をserver.tsに追加する。/images/a/b/c/hoge.jpgみたいなアクセスはa/b/c/hoge.jpgをkeyとしてR2から画像を取得して返す。
code:server.ts
if (url.pathname.startsWith("/images/")) {
const object = await env.BUCKET.get(
url.pathname.replace("/images", "").slice(1)
);
if (object === null) {
return new Response("Object Not Found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
return new Response(object.body, {
headers,
});
}
実装
ブックマークした先のHTMLからタイトルやOGPを取得する。巷のhtml parserはnode依存のものが多くWorkersのランタイムで使えるいいのが見つからなかったので正規表現で適当にやるだけ。
取得したOGP情報(今回はタイトルとopg画像)をDBに登録する。OGP画像に関してはR2にアップロードする。
本番環境で動かす
ここまでで一旦ローカル環境では一通り動くものができた。次はこれを本番環境で動かす。QueueやR2はPaid Planじゃないと使えないので$5/monthの課金が必要。
Bindingsの設定
D1,KV,R2,Queue全てにおいてBindingの設定が必要。手動で管理画面からPages&Workers > Settings > Functionsでそれぞれ設定する。
環境変数の設定
Pages&Workers > Settings > Environment variablesで手動設定。
D1へmigrationの適用
code:sh
npx migrations:apply
デプロイ
code:sh
npm run pages:deploy
デバッグ
下記のコマンドでFunctionsへのリクエストのログが見られる。
code:sh
npx wrangler pages deployment tail
検証
実際にデプロイしたhttps://hoge.pages.dev/へアクセスしてテストしてみる。LoginしようとするとApplication Errorになった。ログを見てみるとTypeError: Cannot read properties of undefined (reading 'startsWith')と出た。
DBへのアクセスがうまくできてなかった。
context.env.DBとアクセスしていてローカルではそれで動いていたが、本番ではcontext.DBとアクセスしないとダメだった。
あ〜結局この問題にぶち当たるのか〜〜
この問題調べてみたらnextjsもnext-on-pagesも通さず_worker.jsのみでもD1もbindingにアクセスできてなかったです。cc: @razokulover > @mugi_uno
envダンプしてみると__D1_BETA__DBっていう謎の値になっててこれも呼び出せない。
D1以外のbindingは使えました KV, R2, SB とか
_worker.jsからだとD1にアクセスできないんだった。。
代替案としては
QueueのConsumerをWorkerで別途作成しPages Functionで管理する
Pagesを使わずにWorkerで全部管理する
リンク
このメモのアプリを格納した途中経過のリポジトリ